Skip to content

[PM-33982] feat: Add device management screen#6754

Open
aj-rosado wants to merge 18 commits intomainfrom
PM-33982/build-device-screen
Open

[PM-33982] feat: Add device management screen#6754
aj-rosado wants to merge 18 commits intomainfrom
PM-33982/build-device-screen

Conversation

@aj-rosado
Copy link
Copy Markdown
Contributor

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-33982

📔 Objective

Add a new screen for device management on Android.
This new screen should display the current device, followed by the pending authorization requests and finally all devices with active sessions ordered by "last active date".

📸 Screenshots

Manage_devices_screen Account_security_options_screen

@aj-rosado aj-rosado requested review from a team and david-livefront as code owners April 1, 2026 18:27
@aj-rosado aj-rosado added the innovation Feature work related to Innovation Sprint or BEEEP projects label Apr 1, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 1, 2026

Logo
Checkmarx One – Scan Summary & Detailsdca152ec-1152-4aaa-866d-7e144c85a8de

Great job! No new security vulnerabilities introduced in this pull request

# Conflicts:
#	app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt
#	app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt
#	app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt
#	core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt
#	ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt
#	ui/src/main/res/values/strings.xml
@github-actions github-actions Bot added app:password-manager Bitwarden Password Manager app context app:authenticator Bitwarden Authenticator app context t:feature Change Type - Feature Development labels Apr 1, 2026
@aj-rosado aj-rosado added the ai-review-vnext Request a Claude code review using the vNext workflow label Apr 1, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 1, 2026

Codecov Report

❌ Patch coverage is 90.27027% with 54 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.72%. Comparing base (3845c1f) to head (07d72b6).

Files with missing lines Patch % Lines
...countsecurity/managedevices/ManageDevicesScreen.kt 92.82% 9 Missing and 9 partials ⚠️
...ntsecurity/managedevices/ManageDevicesViewModel.kt 91.84% 4 Missing and 11 partials ⚠️
...ecurity/managedevices/util/DeviceTypeExtensions.kt 62.06% 10 Missing and 1 partial ⚠️
...tsecurity/managedevices/ManageDevicesNavigation.kt 28.57% 5 Missing ⚠️
...th/repository/util/DeviceResponseJsonExtensions.kt 69.23% 3 Missing and 1 partial ⚠️
...tings/accountsecurity/AccountSecurityNavigation.kt 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6754      +/-   ##
==========================================
- Coverage   85.80%   85.72%   -0.09%     
==========================================
  Files         835      844       +9     
  Lines       59391    60010     +619     
  Branches     8668     8730      +62     
==========================================
+ Hits        50963    51446     +483     
- Misses       5441     5555     +114     
- Partials     2987     3009      +22     
Flag Coverage Δ
app-data 17.42% <3.07%> (-0.13%) ⬇️
app-ui-auth-tools 19.95% <0.00%> (-0.18%) ⬇️
app-ui-platform 16.52% <87.20%> (+0.66%) ⬆️
app-ui-vault 25.45% <0.00%> (-0.23%) ⬇️
authenticator 6.54% <0.00%> (-0.11%) ⬇️
lib-core-network-bridge 4.22% <0.19%> (-0.05%) ⬇️
lib-data-ui 1.01% <0.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

daysAgo < 7 -> BitwardenString.active_this_week
daysAgo < 14 -> BitwardenString.active_last_week
daysAgo < 30 -> BitwardenString.active_this_month
else -> BitwardenString.active_over_thirty_days_ago
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is all based on number of days in a week/month. Should it be based on the day of the week?

If today is Monday, 3 days ago is last week, not this week, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have mirrored the other clients logic but that makes sense to me 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have updated the texts so that it is clearer 🙌

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The language does match the functionality now. But don't we want it to say this week, not last seven days?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was decided to keep it simpler as this change will affect all other clients. So we are keeping the logic and adapting the texts.

* requiring rounding (unlike the JavaScript equivalent).
*/
@Suppress("MagicNumber")
fun Instant?.toLastActivityLabel(clock: Clock, resourceManager: ResourceManager): String? {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a UI layer component, can we just return a Text?

24 -> DeviceTypeEntry(BitwardenString.cli, "MacOs")
25 -> DeviceTypeEntry(BitwardenString.cli, "Linux")
26 -> DeviceTypeEntry(BitwardenString.extension, "DuckDuckGo")
else -> return resourceManager.getString(BitwardenString.unknown_device)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing here, can we just return a Text

19 -> DeviceTypeEntry(BitwardenString.extension, "Vivaldi")
20 -> DeviceTypeEntry(BitwardenString.extension, "Safari")
21 -> DeviceTypeEntry(BitwardenString.sdk, "")
22 -> DeviceTypeEntry(BitwardenString.server, "")
Copy link
Copy Markdown
Collaborator

@david-livefront david-livefront Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these say something?

Also, should these platforms be translatable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other clients are returning the same data.
Regarding translations, nothing is translatable on other clients 🤔

It makes sense to me to be able to translate words like "server" or "extension"


ManageDevicesState.ViewState.Error -> BitwardenErrorContent(
message = stringResource(
id = BitwardenString.generic_error_message,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have nothing more specific than this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not, mobile was based roughly on existing Pending Auth screen and Manage Devices extension screen.
Probably will see some more design work before being approved

)
}

else -> SessionItem(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this exhaustive

Change return type from ui extensions from String to Text
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 2, 2026

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

Reviewed the new device management screen feature, including the AuthenticatedDevicesApi.getDevices integration, AuthRepository.getDevices mapping, the ManageDevices feature flag wiring, and the new ManageDevicesScreen/ManageDevicesViewModel plus their unit/Compose tests. The latest commit also removed the nullable parameter from toLastActivityLabel, addressing earlier reviewer feedback. One previously identified correctness issue (hideBottomSheet boolean inversion at ManageDevicesViewModel.kt:296-299) remains unresolved and is already tracked in the existing thread, so no new inline comments are posted in this pass.

Code Review Details
  • ⚠️ : hideBottomSheet boolean inversion prevents "Skip for now" from dismissing the notifications bottom sheet on F-Droid (and pre-TIRAMISU). Already tracked.
    • app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/managedevices/ManageDevicesViewModel.kt:296

daysAgo < 30 -> BitwardenString.past_thirty_days
else -> BitwardenString.over_thirty_days_ago
}
return resourceManager.getString(resId).asText()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that you are returning a Text you do not need the ResourceManager here anymore.

* Returns e.g. "Mobile - Android", "Extension - Chrome", "Desktop - Windows".
*/
@Suppress("CyclomaticComplexMethod", "MagicNumber")
fun Int.toReadableDeviceTypeName(resourceManager: ResourceManager): Text {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, the ResourceManager is unnecessary

# Conflicts:
#	core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt
Comment on lines +313 to +316
val hideBottomSheet: Boolean
get() = internalHideBottomSheet &&
!isFdroid &&
isBuildVersionAtLeast(Build.VERSION_CODES.TIRAMISU)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: Boolean logic is inverted -- on F-Droid builds (and pre-TIRAMISU), hideBottomSheet always returns false, so the "Skip for now" button cannot dismiss the notification bottom sheet.

Details and fix

The current AND logic requires all three conditions to be true simultaneously. When isFdroid is true, the expression !isFdroid evaluates to false, making the entire result false regardless of internalHideBottomSheet. This means:

  1. On F-Droid with API 33+: the bottom sheet shows (asking to enable push notifications that don't work on F-Droid) and cannot be dismissed.
  2. On pre-TIRAMISU: same undismissable state, though the permission check in the Screen may mask this in some cases.

The fix is to use OR logic so that any single reason to hide the sheet is sufficient:

Suggested change
val hideBottomSheet: Boolean
get() = internalHideBottomSheet &&
!isFdroid &&
isBuildVersionAtLeast(Build.VERSION_CODES.TIRAMISU)
val hideBottomSheet: Boolean
get() = internalHideBottomSheet ||
isFdroid ||
!isBuildVersionAtLeast(Build.VERSION_CODES.TIRAMISU)

The @ChecksSdkIntAtLeast annotation on this property should also be removed since the corrected logic no longer solely checks for a minimum SDK level.

return if (entry.platform.isNotEmpty()) {
entry.categoryResId.asText()
.concat(
" - ${entry.platform}".asText(),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of concat, can we make these format args?

<string name="continue_without_syncing">Continue without syncing</string>
<string name="external_link">External link</string>
<string name="external_link_format" comment="Used for accessibility to indicate that tapping this item will leave the app">%1$s, External link</string>
<string name="manage_devices">Manage devices</string>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This managed devices string is being used in both prod code and for feature flags.

The feature flag name should be in the unlocalized file.

authRequestsLoaded = false,
)
}
updateAuthRequestList()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, why restart the flow here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Want to force to get updated data when user pulls to refresh as this is an operation that has a time limit

private fun fetchAllDevices() {
devicesJob.cancel()
devicesJob = viewModelScope.launch {
coroutineScope {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of the coroutineScope here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was to couple both calls together (one needs the other). Although the GetDeviceByIdentifier is not necessary 🤔

@Parcelize
data class ManageDevicesState(
val authRequests: List<AuthRequest>,
val devices: List<DeviceInfo>,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make all these lists into ImmuableLists

20 -> DeviceTypeEntry(BitwardenString.extension_platform, "Safari")
21 -> DeviceTypeEntry(BitwardenString.sdk, "")
22 -> DeviceTypeEntry(BitwardenString.server, "")
23 -> DeviceTypeEntry(BitwardenString.cli_platform, "Windows")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this intermediary step?

Can we just return the correct value here?

) {
val filteredRequests = when (val result = action.authRequestsUpdatesResult) {
is AuthRequestsUpdatesResult.Update ->
result.authRequests.filterRespondedAndExpired(clock = clock)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we wrap this in curly braces.

assertEquals(DeviceSessionStatus.Pending, content.items[1].status)
assertEquals(DeviceSessionStatus.None, content.items[2].status)
assertEquals(currentDevice.id, content.items[0].id)
assertEquals(pendingDevice.id, content.items[1].id)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be comparing again the entire ManageDevicesState, not individual ids and statuses

val viewModel = createViewModel()
assertEquals(
ManageDevicesState.ViewState.Error,
viewModel.stateFlow.value.viewState,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should compare the entire thing, not just the viewSTate

timeStyle = FormatStyle.MEDIUM,
clock = fixedClock,
)
} returns "Oct 27, 2023, 12:00:00 PM"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed? The instant is fixed, so it should always return the same string

fun `type 1 should return Mobile - iOS`() {
assertEquals(
"Mobile - iOS",
1.readableDeviceTypeName.toString(resources),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of resource mocking is needed, you can just compare the outputs:

assertEquals(
    BitwardenString.mobile_platform.asText("iOS"),
    1.readableDeviceTypeName,
)

# Conflicts:
#	app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt
val device = result.devices.first()
assertEquals("deviceId", device.id)
// identifier "deviceIdentifier" != uniqueAppId "testUniqueAppId", so not current device
assertFalse(device.isCurrentDevice)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be comparing the entire result

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review-vnext Request a Claude code review using the vNext workflow app:authenticator Bitwarden Authenticator app context app:password-manager Bitwarden Password Manager app context innovation Feature work related to Innovation Sprint or BEEEP projects t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants